昨天做了檔案歸檔器,今天把它改成GUI版本!
環境
Python 3.x(內建 Tkinter)→ 不用額外安裝套件。
程式碼(存成 file_sorter_gui.py)
# file_sorter_gui.py — Day 18 GUI:依副檔名/日期歸檔(move/copy/試跑/CSV/進度條)
from __future__ import annotations
import threading, shutil, csv, time, os, sys, subprocess
from pathlib import Path
from typing import Iterable, List, Tuple
from tkinter import Tk, StringVar, BooleanVar, IntVar, filedialog, messagebox
from tkinter import ttk
from tkinter.scrolledtext import ScrolledText
# ---------- 共用邏輯 ----------
def iter_files(root: Path, recursive: bool, patterns: List[str] | None) -> Iterable[Path]:
pats = patterns or ["*"]
seen = set()
for pat in pats:
glob = root.rglob if recursive else root.glob
for p in glob(pat):
if p.is_file():
rp = p.resolve()
if rp not in seen:
seen.add(rp)
yield p
def date_folder(p: Path) -> Path:
st = p.stat()
y = time.strftime("%Y", time.localtime(st.st_mtime))
m = time.strftime("%m", time.localtime(st.st_mtime))
return Path(y) / m # 例如 2025/09
def ext_folder(p: Path) -> Path:
ext = p.suffix.lower().lstrip(".") or "_noext"
return Path(ext)
def unique_path(dst: Path) -> Tuple[Path, str]:
"""避免覆蓋:若存在就自動加 _1, _2...,回傳(路徑, 註記)"""
if not dst.exists():
return dst, ""
base, ext = dst.stem, dst.suffix
k = 1
while True:
alt = dst.with_name(f"{base}_{k}{ext}")
if not alt.exists():
return alt, f"rename({k})"
k += 1
def write_csv(rows: List[dict], out: Path):
out.parent.mkdir(parents=True, exist_ok=True)
cols = ["action","src","dst","note"]
with out.open("w", encoding="utf-8", newline="") as f:
w = csv.DictWriter(f, fieldnames=cols)
w.writeheader()
for r in rows: w.writerow(r)
def open_folder(path: Path):
try:
if sys.platform.startswith("win"):
os.startfile(str(path))
elif sys.platform == "darwin":
subprocess.run(["open", str(path)])
else:
subprocess.run(["xdg-open", str(path)])
except Exception:
pass
# ---------- GUI App ----------
class SorterApp:
def __init__(self):
self.root = Tk()
self.root.title("檔案歸檔器 (Day 18 GUI)")
self.root.geometry("820x520")
# 狀態
self.total = IntVar(value=0)
self.done = IntVar(value=0)
# 參數
self.src = StringVar()
self.dst = StringVar()
self.by = StringVar(value="ext") # ext / date
self.mode = StringVar(value="move") # move / copy
self.recursive = BooleanVar(value=True)
self.patterns = StringVar(value="*.pdf, *.jpg, *.png") # 逗號或空白分隔
self.apply_flag = BooleanVar(value=False) # False = dry-run
self.out_csv = StringVar(value=str(Path("exports/sort_log.csv")))
self._build_ui()
# ---- UI ----
def _build_ui(self):
pad = {"padx": 8, "pady": 6}
# 第1行:來源
row = ttk.Frame(self.root); row.grid(row=0, column=0, sticky="we", **pad); row.columnconfigure(1, weight=1)
ttk.Label(row, text="來源資料夾").grid(row=0, column=0, sticky="w")
ttk.Entry(row, textvariable=self.src).grid(row=0, column=1, sticky="we")
ttk.Button(row, text="選擇…", command=self.pick_src).grid(row=0, column=2)
# 第2行:目標
row = ttk.Frame(self.root); row.grid(row=1, column=0, sticky="we", **pad); row.columnconfigure(1, weight=1)
ttk.Label(row, text="目標資料夾").grid(row=0, column=0, sticky="w")
ttk.Entry(row, textvariable=self.dst).grid(row=0, column=1, sticky="we")
ttk.Button(row, text="選擇…", command=self.pick_dst).grid(row=0, column=2)
# 第3行:分類/動作/遞迴
row = ttk.Frame(self.root); row.grid(row=2, column=0, sticky="we", **pad)
ttk.Label(row, text="分類方式").grid(row=0, column=0)
ttk.Combobox(row, values=["ext","date"], textvariable=self.by, width=8, state="readonly").grid(row=0, column=1, padx=(6,12))
ttk.Label(row, text="動作").grid(row=0, column=2)
ttk.Combobox(row, values=["move","copy"], textvariable=self.mode, width=8, state="readonly").grid(row=0, column=3, padx=(6,12))
ttk.Checkbutton(row, text="包含子資料夾 (recursive)", variable=self.recursive).grid(row=0, column=4)
# 第4行:過濾
row = ttk.Frame(self.root); row.grid(row=3, column=0, sticky="we", **pad); row.columnconfigure(1, weight=1)
ttk.Label(row, text="過濾 patterns").grid(row=0, column=0, sticky="w")
ttk.Entry(row, textvariable=self.patterns).grid(row=0, column=1, sticky="we")
ttk.Label(row, text="(逗號或空白分隔,如:*.pdf, *.jpg)").grid(row=0, column=2, sticky="w", padx=(6,0))
# 第5行:CSV 輸出
row = ttk.Frame(self.root); row.grid(row=4, column=0, sticky="we", **pad); row.columnconfigure(1, weight=1)
ttk.Label(row, text="CSV 輸出").grid(row=0, column=0, sticky="w")
ttk.Entry(row, textvariable=self.out_csv).grid(row=0, column=1, sticky="we")
ttk.Button(row, text="瀏覽…", command=self.pick_csv).grid(row=0, column=2)
# 第6行:按鈕
row = ttk.Frame(self.root); row.grid(row=5, column=0, sticky="w", **pad)
ttk.Button(row, text="試跑 (Dry-run)", command=lambda: self.start(False)).grid(row=0, column=0, padx=(0,6))
ttk.Button(row, text="真的執行 (Apply)", command=lambda: self.start(True)).grid(row=0, column=1, padx=(0,6))
ttk.Button(row, text="打開輸出資料夾", command=self.open_exports).grid(row=0, column=2)
# 第7行:進度/狀態
row = ttk.Frame(self.root); row.grid(row=6, column=0, sticky="we", **pad); row.columnconfigure(0, weight=1)
self.prog = ttk.Progressbar(row, length=700, mode="determinate", maximum=100)
self.prog.grid(row=0, column=0, sticky="we")
self.status = ttk.Label(row, text="等待開始…"); self.status.grid(row=1, column=0, sticky="w", pady=(4,0))
# 第8行:日誌
row = ttk.Frame(self.root); row.grid(row=7, column=0, sticky="nsew", **pad)
self.root.rowconfigure(7, weight=1); row.rowconfigure(0, weight=1); row.columnconfigure(0, weight=1)
self.log = ScrolledText(row, height=10)
self.log.grid(row=0, column=0, sticky="nsew")
# ---- 檔案對話盒 ----
def pick_src(self):
d = filedialog.askdirectory(title="選擇來源資料夾")
if d: self.src.set(d)
def pick_dst(self):
d = filedialog.askdirectory(title="選擇目標資料夾")
if d: self.dst.set(d)
def pick_csv(self):
f = filedialog.asksaveasfilename(title="選擇 CSV 輸出檔", defaultextension=".csv",
filetypes=[("CSV", "*.csv")], initialfile="sort_log.csv")
if f: self.out_csv.set(f)
def open_exports(self):
out = Path(self.out_csv.get().strip() or "exports/sort_log.csv")
open_folder(out.parent if out.suffix else Path(out))
# ---- 工具 ----
def _post(self, func, *a, **kw):
self.root.after(0, lambda: func(*a, **kw))
def _set_status(self, text: str):
self.status.config(text=text)
def _append_log(self, text: str):
self.log.insert("end", text.rstrip() + "\n"); self.log.see("end")
def _set_progress(self, done: int, total: int):
pct = 0 if total == 0 else int(done * 100 / total)
self.prog["value"] = pct
self.status.config(text=f"處理中:{done}/{total} ({pct}%)")
# ---- 執行 ----
def start(self, apply_flag: bool):
src = Path(self.src.get().strip())
dst = Path(self.dst.get().strip())
if not src.exists():
messagebox.showerror("錯誤", "來源資料夾不存在"); return
if not dst.exists():
try:
dst.mkdir(parents=True, exist_ok=True)
except Exception as e:
messagebox.showerror("錯誤", f"無法建立目標資料夾:\n{e}"); return
pats = [p.strip() for p in self.patterns.get().replace(",", " ").split() if p.strip()]
by = self.by.get()
mode = self.mode.get()
recursive = self.recursive.get()
out_csv = Path(self.out_csv.get().strip() or "exports/sort_log.csv")
# 重置 UI
self.log.delete("1.0", "end")
self.prog["value"] = 0
self._set_status("掃描檔案中…")
t = threading.Thread(target=self._worker, args=(src, dst, by, mode, recursive, pats, apply_flag, out_csv), daemon=True)
t.start()
def _worker(self, src: Path, dst: Path, by: str, mode: str, recursive: bool,
pats: List[str], apply_flag: bool, out_csv: Path):
try:
files = list(iter_files(src, recursive, pats))
total = len(files)
self._post(self._set_progress, 0, total)
if total == 0:
self._post(self._set_status, "找不到檔案(檢查來源/過濾條件)")
return
rows = []
done = 0
self._post(self._append_log, f"共 {total} 個檔案;模式={mode},分類={by},{'Apply' if apply_flag else 'Dry-run'}")
for p in files:
sub = ext_folder(p) if by == "ext" else date_folder(p)
out_dir = (dst / sub)
out_dir.mkdir(parents=True, exist_ok=True)
target = out_dir / p.name
final, note = unique_path(target)
if apply_flag:
try:
if mode == "move":
shutil.move(str(p), str(final))
else:
shutil.copy2(str(p), str(final))
rows.append({"action": mode, "src": str(p), "dst": str(final), "note": note})
self._post(self._append_log, f"✔ {mode}: {p} → {final}")
except Exception as e:
rows.append({"action": "error", "src": str(p), "dst": str(final), "note": str(e)})
self._post(self._append_log, f"✖ error: {p} → {final} ({e})")
else:
rows.append({"action": f"plan-{mode}", "src": str(p), "dst": str(final), "note": note})
done += 1
if done % 10 == 0 or done == total:
self._post(self._set_progress, done, total)
write_csv(rows, out_csv)
if apply_flag:
self._post(self._set_status, f"完成!已處理 {done} 個,明細:{out_csv}")
self._post(self._append_log, f"完成:已輸出 CSV → {out_csv}")
else:
self._post(self._set_status, f"試跑完成(未動檔)→ {out_csv}")
self._post(self._append_log, f"試跑完成:請檢視 CSV,確認後按 Apply 執行")
except Exception as e:
self._post(self._set_status, f"發生錯誤:{e}")
self._post(self._append_log, f"[Exception] {e}")
def run(self):
self.root.mainloop()
if __name__ == "__main__":
SorterApp().run()
怎麼用(步驟)
實作:


小卡關速修
今日小結
完成一個可視化的「檔案歸檔器」:副檔名/日期分類、移動或複製、Dry-run 預覽、CSV 明細、進度條。